使用代码实现 WSL 的虚拟磁盘自动挂载

在 WSL 2 开发环境中,额外挂载一块磁盘是很常见的需求:隔离数据、放缓存,或者模拟更接近真实 Linux 机器的磁盘布局。

这件事手动做也不麻烦,就几条命令,但是由于业务需要自动化所以研究了下,主要麻烦点就是解决这块硬盘是 /dev/sdc 还是 /dev/sdd的问题。这里记录一下当个纪念。

本文所使用的编程语言是 Rust,主要使用 windowsnix 等 crate。主要用于调用 Windows 的虚拟磁盘相关 API 来创建 VHDX 和 Linux 的一些 API。

本文主要处理两类磁盘:

整体流程如下:

  1. Windows 侧创建 VHDX

  2. 通过 wsl –mount –vhd … –bare 接入 WSL

  3. Linux 侧识别目标设备

  4. 创建分区表

  5. 格式化文件系统

  6. 挂载到指定目录

#关于识别的核心思路

在 VHDX 接入 WSL 后,在 Linux 里通常会变成 /dev/sdX 一类设备名;但这个名字并不稳定,今天可能是 /dev/sdc,下次可能就成了 /dev/sdd ,总之取决于挂载顺序。所以自动化的关键,不是记住设备名,而是给磁盘一个稳定标识,然后在 Linux 侧用这个标识把它找回来。

本文的做法是:

#Windows 侧:创建 VHDX

#权限处理

CreateVirtualDisk 是特权操作。普通权限下调用,大概率就是 ERROR_ACCESS_DENIED,所以第一步不是创建磁盘,而是先检查当前进程是否已提权;如果没有,就用 ShellExecuteW(…, "runas", …) 重新拉起自己。

这段逻辑的目标很简单:

代码如下:

use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOW;
use windows::core::{PCWSTR, HSTRING};
use std::env;

/// 检查并申请管理员权限
pub fn ensure_admin_privileges() -> bool {
    if is_elevated() {
        return true;
    }

    let exe_path = env::current_exe().unwrap();
    let exe_path_wide: Vec<u16> = exe_path
        .to_string_lossy()
        .encode_utf16()
        .chain(std::iter::once(0))
        .collect();

    let args: Vec<String> = env::args().skip(1).collect();
    let args_str = args.join(" ");
    let args_wide: Vec<u16> = args_str
        .encode_utf16()
        .chain(std::iter::once(0))
        .collect();

    let operation = HSTRING::from("runas");

    unsafe {
        ShellExecuteW(
            None,
            PCWSTR::from_raw(operation.as_ptr()),
            PCWSTR::from_raw(exe_path_wide.as_ptr()),
            if args.is_empty() {
                PCWSTR::null()
            } else {
                PCWSTR::from_raw(args_wide.as_ptr())
            },
            PCWSTR::null(),
            SW_SHOW,
        );
    }

    std::process::exit(0);
}

/// 检查当前进程是否已提升权限
fn is_elevated() -> bool {
    use windows::Win32::Security::{
        GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
    };
    use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

    unsafe {
        let mut token = windows::Win32::Foundation::HANDLE::default();

        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_ok() {
            let mut elevation = TOKEN_ELEVATION::default();
            let mut size = 0;

            if GetTokenInformation(
                token,
                TokenElevation,
                Some(&mut elevation as *mut _ as *mut _),
                std::mem::size_of::<TOKEN_ELEVATION>() as u32,
                &mut size,
            ).is_ok()
            {
                return elevation.TokenIsElevated != 0;
            }
        }
    }

    false
}

#创建 VHDX

提权完成后,就可以调用 CreateVirtualDisk 创建 VHDX 了。

这里不要随机生成 GUID。需要显式传入一个 GUID,这个 ID 就是这块磁盘的后面在 WSL 下识别所需要的。

代码如下:

use windows::core::{GUID, PCWSTR};
use windows::Win32::Storage::Vhd::{
    CreateVirtualDisk, CREAT_VIRTUAL_DISK_PARAMETERS, CREATE_VIRTUAL_DISK_VERSION_2,
    VIRTUAL_DISK_ACCESS_NONE, CREATE_VIRTUAL_DISK_FLAG_FULL_PHYSICAL_ALLOCATION,
    VIRTUAL_STORAGE_TYPE, VIRTUAL_STORAGE_TYPE_DEVICE_VHDX, VIRTUAL_STORAGE_TYPE_VENDOR_MICROSOFT,
};

pub fn create_vhdx(path: &str, size_bytes: u64, unique_id: GUID) -> windows::core::Result<()> {
    let storage_type = VIRTUAL_STORAGE_TYPE {
        DeviceId: VIRTUAL_STORAGE_TYPE_DEVICE_VHDX,
        VendorId: VIRTUAL_STORAGE_TYPE_VENDOR_MICROSOFT,
    };

    let mut params: CREATE_VIRTUAL_DISK_PARAMETERS = unsafe { std::mem::zeroed() };
    params.Version = CREATE_VIRTUAL_DISK_VERSION_2;

    unsafe {
        let v2 = &mut params.Anonymous.Version2;
        v2.UniqueId = unique_id;
        v2.MaximumSize = size_bytes;
        v2.BlockSizeInBytes = 0;
        v2.SectorSizeInBytes = 0;
    }

    let path_wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
    let mut handle = windows::Win32::Foundation::HANDLE::default();

    unsafe {
        CreateVirtualDisk(
            &storage_type,
            PCWSTR::from_raw(path_wide.as_ptr()),
            VIRTUAL_DISK_ACCESS_NONE,
            None,
            CREATE_VIRTUAL_DISK_FLAG_FULL_PHYSICAL_ALLOCATION,
            0,
            &params,
            None,
            &mut handle,
        )
    }
}

这里偏向使用固定大小预分配的 VHDX。自动扩容的性能可能没有固定大小预分配的好,且在宿主机不断增长容量感觉也很烦。

创建完成后,在 Windows 侧执行执行命令:

wsl --mount --vhd <path> --bare

–bare 的意思是“只把这块磁盘接进 WSL,不自动挂载文件系统”。到这里为止,Linux 内核已经能看到这块盘了,但它还没有分区、也没有文件系统,更没有挂载点。下一步要转到 Linux 侧处理。

#Linux 侧:识别正确的设备

#查找设备

VHDX 接进 WSL 后,常见表现是一个新的 /dev/sdX 设备。但这个名字不稳定,不能拿来做自动化。

所以这里需要一个稳定识别策略。

本文的的做法在前面提到过,主要是:在 Windows 侧为 VHDX 指定 GUID,Linux 侧根据这个 GUID 推导出预期 WWID,然后遍历 /sys/block/*/device/wwid 做匹配。

#关于 GUID 到设备标识的匹配

Linux 下块设备的标识,常见可以从 /sys/block/<dev>/device/serial/sys/block/<dev>/device/wwid 读取。这里使用的是 WWID

VHDX 的 GUID 和 WWID 是有关联的,首先 naa.60022480 的前缀是固定的,表示 Microsoft 的 SCSI vendor namespace,然后取前几位反转字节序后再拼接末尾 6 字节就是 WWID 了。

一个完整的 GUID 是 16 字节的:00112233-4455-6677-8899-aabbccddeeff, 而 WWID 总长也是固定的 16 字节,这里前缀用了 4 字节,末尾要用 6 字节,还剩 6 字节,反转一下 Data1Data2 的字节序即反转 00112233-4455 这一部分就是这 6 字节了。

代码如下:

use std::io;

/// 读取 /sys 下设备属性
fn read_sys_attr(dev_name: &str, attr: &str) -> Option<String> {
    let path = format!("/sys/block/{}/device/{}", dev_name, attr);
    std::fs::read_to_string(&path).ok().map(|s| s.trim().to_string())
}

/// 将 32 位不带分隔符的 GUID(VHDX UniqueId)转换为候选微软 NAA WWID:
/// "naa.60022480" + 一组按当前环境实测可用的字节重排
fn msft_naa_wwid_from_guid_hex(hex: &str) -> Option<String> {
    let s = hex.trim();
    if s.len() != 32 {
        return None;
    }

    let bytes = (0..32)
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
        .collect::<Option<Vec<u8>>>()?;

    let mut out = Vec::with_capacity(12);

    // Data1 u32(Windows 二进制表示) -> 调整字节顺序
    out.extend_from_slice(&[bytes[3], bytes[2], bytes[1], bytes[0]]);
    // Data2 u16 -> 调整字节顺序
    out.extend_from_slice(&[bytes[5], bytes[4]]);
    // 仅取后 6 字节,拼出当前环境下可匹配的 12 字节 body
    out.extend_from_slice(&bytes[10..16]);

    let body = out.iter().map(|b| format!("{:02x}", b)).collect::<String>();
    Some(format!("naa.60022480{}", body))
}

/// 遍历 /sys/block 找盘
pub fn find_device_by_guid(guid_hex: &str) -> std::io::Result<Option<String>> {
    let expected = msft_naa_wwid_from_guid_hex(guid_hex).unwrap();
    
    for entry in std::fs::read_dir("/sys/block")? {
        let name = entry?.file_name().into_string().unwrap();
        let wwid_path = format!("/sys/block/{}/device/wwid", name);
        
        if let Ok(wwid) = std::fs::read_to_string(wwid_path) {
            if wwid.trim().eq_ignore_ascii_case(&expected) {
                return Ok(Some(format!("/dev/{}", name))); // 找到了!例如 /dev/sdc
            }
        }
    }
    Ok(None)
}

#创建分区

在通过 WWID 找到磁盘设备后,比如 /dev/sdd,下一步通常是创建 GPT 分区表并加一个分区。

可以通过命令创建分区,但是在最小化 Linux 分发版中,不一定具备分区工具,所以本文采用了 gpt crate 来创建分区, 然后就踩了一个坑:分区表写到磁盘上,并不等于内核立刻知道了它变了

常见工具如 fdisksfdisk 在更新分区表后,会触发内核重新读取分区表。所以这里也需要补这一段, 调用 ioctl(fd, BLKRRPART, …),强制让内核重新扫描分区表:

use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;

pub fn partition_and_sync(device_path: &str) -> std::io::Result<()> {
    let mut disk = gpt::GptConfig::new()
        .writable(true)
        .create(device_path)?;

    disk.add_partition("DATA", 0, gpt::partition_types::LINUX_FS, 0, None)?;
    disk.write()?;

    let file = OpenOptions::new().read(true).write(true).open(device_path)?;
    let fd = file.as_raw_fd();

    const BLKRRPART: libc::c_ulong = 0x125F;
    let result = unsafe { libc::ioctl(fd, BLKRRPART, 0) };

    if result != 0 {
        return Err(std::io::Error::last_os_error());
    }
    // 偷懒写法,更稳妥的做法是轮询等待分区节点出现,而不是盲等固定时间
    std::thread::sleep(std::time::Duration::from_millis(500));
    Ok(())
}

#格式化分区

分区出来后,通常会格式化成 ext4,这里没有找到合适的 crate, 只能用命令了。

在这一步可以配置一下保留块比例。默认情况下,ext 系文件系统通常会给超级用户预留 5% 空间。系统盘上这很合理,但如果这块盘只是你的数据盘或缓存盘,默认值就未必划算了。

本文的采取的策略是:

这不是硬规则,取决于实际情况要不要多拿一点可用空间。

具体代码如下:

use std::process::Command;

pub fn format_ext4(partition_path: &str, reserved_percent: u8) -> std::io::Result<()> {
    let status = Command::new("mke2fs")
        .arg("-t").arg("ext4")
        .arg("-F")
        .arg("-m").arg(reserved_percent.to_string())
        .arg(partition_path)
        .status()?;

    if !status.success() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::Other,
            "Format failed",
        ));
    }

    Ok(())
}

#挂载分区

挂载有一些选项,根据磁盘的用途来配置,对于本文的场景会用的下面的选项:

原因很简单:对于缓存来说访问时间基本没用,但每次读文件都去更新一次元数据,也有些开销。

代码如下:

use nix::mount::{mount, MsFlags};

pub fn mount_disk(device: &str, target: &str) -> std::io::Result<()> {
    std::fs::create_dir_all(target)?;

    let flags = MsFlags::MS_NOATIME | MsFlags::MS_NODIRATIME;

    mount(
        Some(device),
        target,
        Some("ext4"),
        flags,
        None::<&str>,
    )
    .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;

    Ok(())
}

如果更在意时间戳语义,也可以研究 lazytime。但对偏开发缓存、构建输出、数据隔离这类用途来说,先把 atime 写入关掉,收益已经很明显了。

#幂等性

为了防止意外被重新格式化,所以每个步骤要考虑幂等性

例如以下情况:

如果每一步都带状态检查,就可以在启动时反复执行,而不用担心误伤已有数据。总之先检查,再执行;能跳过就跳。只有这样才能放心挂到自动化流程里。

#小结

这篇的重点其实不是“如何调用某个 API”,而是把整条链路串起来:

回头看,这里面真正值得记住的点就几个:

  1. 创建 VHDX 需要管理员权限

  2. wsl –mount –vhd –bare 只是接入,不是最终挂载

  3. /dev/sdX 不稳定,自动化必须找唯一标识

  4. 写完分区表后,要记得通知内核重扫

  5. 格式化和挂载都值得顺手做一点参数调优

  6. 能直接复用系统稳定能力的地方,优先复用

剩下的,其实就是把这些点老老实实写进代码里。

5436 Words